DynamoDB上のデータをファイルにエクスポートする際のJSONシリアライズについて考えてみる(スクリプト付き)
こんにちは、CX事業本部の若槻です。
Pythonによる実装で、辞書(dict型オブジェクト)データをファイルに書き込んだり、AWS LambdaからLambdaを呼び出す際のペイロードに指定したりする場合は、その辞書データは事前にjson.dump()
やjson.dumps()
などのJSONエンコーダによりJSONシリアライズされている必要があります。
今回は、DynamoDBから取得したデータをローカルファイルに書き込み(エクスポート)する際のJSONシリアライズの方法について考えてみた上で、一連の処理を行うPythonスクリプトを作ってみました。
DynamoDBから取得したデータはどんな型を取りうる?
まず、そもそもDynamoDBから取得したデータはどんな型を取りうるのかを確認してみます。
AWSドキュメント[命名ルールおよびデータ型 - Amazon DynamoDB]によると、DynamoDBでは以下の型のデータが格納可能とのことです。
- スカラー型
- 数値(Number)
- 文字列(String)
- バイナリ(Binary)
- ブール(Boolean)
- null(Null)
- ドキュメント型
- リスト(List)
- マップ(Map)
- セット型
- 文字セット(StringSet)
- 数値セット(NumberSet)
- バイナリセット(BinarySet)
実際にテーブルを作成(sample-table
)し、上記10種の型の値を含むデータを登録します。
下記Pythonスクリプトを使用してテーブル上のデータを取得し、各キーの値と型を確認してみます。
import json import boto3 dynamodb = boto3.resource('dynamodb') table_name = 'sample-table' table = dynamodb.Table(table_name) resp = table.scan() items = resp['Items'] for item in items: for kv in item.items(): _, v = kv print(kv, type(v))
取得結果は以下のようになりました。
$ python scan.py ('k01', Decimal('1234567890')) <class 'decimal.Decimal'> ('k02', 'テスト') <class 'str'> ('k03', Binary(b'Binary Data')) <class 'boto3.dynamodb.types.Binary'> ('k04', True) <class 'bool'> ('k05', None) <class 'NoneType'> ('k06', ['val1', Decimal('9876543210')]) <class 'list'> ('k07', {'key': 'hoge'}) <class 'dict'> ('k08', {'bar', 'foo'}) <class 'set'> ('k09', {Decimal('222'), Decimal('111')}) <class 'set'> ('k10', {Binary(b'Binary Set 2'), Binary(b'Binary Set 1')}) <class 'set'>
DynamoDB登録時の型と、取得したデータの型の対応をまとめると以下のようになります。
DynamoDB登録時の型 | キー名 | 取得したデータの型 |
---|---|---|
数値(Number) | k01 | decimal.Decimal |
文字列(String) | k02 | str |
バイナリ(Binary) | k03 | boto3.dynamodb.types.Binary(bytes) |
ブール(Boolean) | k04 | bool |
null(Null) | k05 | NoneType |
リスト(List) | k06 | list |
マップ(Map) | k07 | dict |
文字セット(StringSet) | k08 | set{str} |
数値セット(NumberSet) | k09 | set{decimal.Decimal} |
バイナリセット(BinarySet) | k10 | set(boto3.dynamodb.types.Binary(bytes)) |
よって、DynamoDBから取得したデータは以下の型を取りうることが分かりました
decimal.Decimal
str
boto3.dynamodb.types.Binary
bytes
bool
NoneType
list
dict
set
DynamoDBから取得したデータのうち既定でJSONシリアライズができないデータ型はどれ?
次に、DynamoDBから取得したデータのうち既定でJSONシリアライズができないデータ型はどれであるか確認してみます。
以下のPythonドキュメントによると、json.dump()
やjson.dumps()
で使われるJSONエンコーダーでデフォルトでJSONシリアライズ可能なデータ型は下記とのことです。
class json.JSONEncoder(*, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False, indent=None, separators=None, default=None)
Extensible JSON encoder for Python data structures.
Supports the following objects and types by default:
Python JSON dict object list, tuple array str string int, float, int- & float-derived Enums number True true False false None null
よって、DynamoDBから取得したデータが取りうる型のうち、既定でJSONシリアライズできない型は以下となることが分かりました。
decimal.Decimal
boto3.dynamodb.types.Binary
bytes
set
json.dump
などによりJSONシリアライズする際はこれらの型のデータをシリアライズ可能な型に変換する必要があります。
デフォルトでJSONシリアライズできない型のデータをシリアライズする
次に、デフォルトでJSONシリアライズできない型のデータをシリアライズする方法について考えてみます。
辞書データをJSONシリアライズしてファイルに書き込むことができるメソッドであるjson.dump()
は、default
オプションにて型変換を実施するメソッドを指定することにより、デフォルトでJSONシリアライズできない型のデータをシリアライズすることができるようになります。
json.dump(obj, fp, *, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, indent=None, separators=None, default=None, sort_keys=False, **kw)
Serialize obj as a JSON formatted stream to fp (a .write()-supporting file-like object) using this conversion table.
If specified, default should be a function that gets called for objects that can’t otherwise be serialized. It should return a JSON encodable version of the object or raise a TypeError. If not specified, TypeError is raised.
そこで、DynamoDBから取得したデータのうち、デフォルトでJSONシリアライズできない型も含めてシリアライズ可能とする以下のようなdefault()
メソッドを作ってみました。
import json from decimal import Decimal from boto3.dynamodb.types import Binary def default(obj) -> object: if isinstance(obj, Decimal): if int(obj) == obj: return int(obj) else: return float(obj) elif isinstance(obj, Binary): return obj.value elif isinstance(obj, bytes): return obj.decode() elif isinstance(obj, set): return list(obj) try: return str(obj) except Exception: return None json.dump(<対象データ>, <書き込み先のファイル>, default=default)
上記のdefault()
メソッドでは、各データ型の変換は下記のように行っています。
変換前の型 | 変換を行う箇所(ブロック) | 変換後の型 |
---|---|---|
decimal.Decimal |
if isinstance(obj, Decimal): |
int またはfloat |
boto3.dynamodb.types.Binary |
elif isinstance(obj, Binary): |
str |
bytes |
elif isinstance(obj, bytes): |
str |
set |
elif isinstance(obj, set): |
list |
使用する際の注意点としては、
- もし上記の4つ以外の型のオブジェクトが含まれていた場合は、
try
ブロックでstr
型かNone
に変換するようにしています。 bytes
型のオブジェクトは.decode()
によりデフォルトのエンコードタイプutf-8
でデコードするようにしています。その他エンコードタイプを使用している場合は、.decode(encoding='<encode type>)'
のように明示的に指定する必要があります。
などです。
一連の処理をスクリプトにしてみた
ここまでの内容を踏まえて、DynamoDB上のデータをファイルにエクスポートする際のスクリプトを作ってみました。
import json import sys from typing import List from decimal import Decimal import boto3 from boto3.resources.base import ServiceResource from boto3.dynamodb.types import Binary def main(table_name: str, dynamodb_resource: ServiceResource) -> None: table_items = scan_table(table_name, dynamodb_resource) put_data(table_items) def scan_table(table_name: str, dynamodb_resource: ServiceResource) -> List[dict]: table = dynamodb_resource.Table(table_name) resp = table.scan() table_items = resp['Items'] while 'LastEvaluatedKey' in resp: resp = table.scan( ExclusiveStartKey=resp['LastEvaluatedKey'] ) table_items.extend(resp['Items']) return table_items def default(obj) -> object: if isinstance(obj, Decimal): if int(obj) == obj: return int(obj) else: return float(obj) elif isinstance(obj, Binary): return obj.value elif isinstance(obj, bytes): return obj.decode(encoding='default') elif isinstance(obj, set): return list(obj) try: return str(obj) except Exception: return None def put_data(table_items: List[dict]) -> None: with open('./export.json', 'w') as f: json.dump(table_items, f, default=default, ensure_ascii=False, sort_keys=True, indent=4) if __name__ == '__main__': table_name = 'sample-table' dynamodb_resource = boto3.resource('dynamodb') main(table_name, dynamodb_resource)
スクリプトを実行すると、指定したDynamoDBテーブルからローカルのexport.json
ファイルにデータがエクスポートされます。(下記は今回作成したsample-table
テーブルの場合)
[ { "k01": 1234567890, "k02": "テスト", "k03": "Binary Data", "k04": true, "k05": null, "k06": [ "val1", 9876543210 ], "k07": { "key": "hoge" }, "k08": [ "foo", "bar" ], "k09": [ 222, 111 ], "k10": [ "Binary Set 1", "Binary Set 2" ] } ]
補足
default
オプションは再帰的に適用される
json.dump
でのdefault
オプションで指定したメソッドは、既定でJSONシリアライズできないオブジェクトに対して再帰的に適用されます。試しにdefault()
メソッドでオブジェクトを何も変換せずに返すようにしてみると、
def default(obj) -> object: return obj
以下のように循環エラーValueError: Circular reference detected
となります。
$ python script.py Traceback (most recent call last): File "script.py", line 41, in <module> main(table_name, dynamodb_resource) File "script.py", line 13, in main put_data(table_items) File "fetcher.py", line 34, in put_data json.dump(table_items, f, default=default, ensure_ascii=False, sort_keys=True, indent=4) File "/usr/lib64/python3.6/json/__init__.py", line 179, in dump for chunk in iterable: File "/usr/lib64/python3.6/json/encoder.py", line 428, in _iterencode yield from _iterencode_list(o, _current_indent_level) File "/usr/lib64/python3.6/json/encoder.py", line 325, in _iterencode_list yield from chunks File "/usr/lib64/python3.6/json/encoder.py", line 404, in _iterencode_dict yield from chunks File "/usr/lib64/python3.6/json/encoder.py", line 438, in _iterencode yield from _iterencode(o, _current_indent_level) File "/usr/lib64/python3.6/json/encoder.py", line 435, in _iterencode raise ValueError("Circular reference detected") ValueError: Circular reference detected
おわりに
データを扱う時に型を意識することはとても大切ですね。
今回は飽くまで"DynamoDBから取得したデータをファイルにエクスポートする場合"に必要な変換処理についてでした。しかしデータの出どころや使用方法が異なる場合でも今回と同様の考え方で変換処理の検討はできると思うのでご参考ください。
以上